昨天替 DevPulse 做了 Prompt Injection 防護測試,確認程式碼註解裡的「忽略前面規則」不應該變成新的指令。不過,安全地完成分析只是其中一步。當 codeReviewFlow 找出問題後,目前最直接的查看方式仍是讀取終端機或 JSON;如果要快速掌握整個檔案的健康狀態,還需要一個更容易閱讀的畫面。
Day 18 已經用 Stitch 做出 DevPulse 儀表板,Day 19 也把產生的 HTML 搬回本機。今天就把兩邊接起來:不重新設計介面,也不增加新的 AI 流程,只把審查 JSON 放進現有頁面。
今天要完成三件事:
前端不需要知道 Gemini 是怎麼分析程式碼的,只需要知道要顯示哪些資料。因此,我先準備一份適合展示的 report.json。以下是示範資料,實際發布時應換成 Day 23 的 codeReviewFlow 真正輸出;如果既有輸出的欄位名稱不同,只要在轉換時對應一次即可,不必為了 UI 重寫後端流程。
在 devpulse/web/ 新增 report.json:
{
"file": "samples/sql.cjs",
"generatedAt": "2026-10-13 14:30",
"findings": [
{
"line": 3,
"category": "security",
"reason": "使用字串串接 SQL,輸入內容可能改變查詢語意。",
"suggestion": "改用參數化查詢,將使用者輸入與 SQL 語法分離。"
},
{
"line": 8,
"category": "logic",
"reason": "目前的處理方式沒有涵蓋預期的邊界情況。",
"suggestion": "補上邊界條件判斷,並使用既有單元測試重新驗證。"
}
]
}
這個格式刻意只保留儀表板真正要用的資訊:審查檔案、產生時間,以及每個問題的行號、分類、原因和建議。不要把整份原始程式碼塞進摘要卡片,否則畫面會很快失去重點。
打開先前匯出的 devpulse/web/index.html。保留原本由 Stitch 生成的側邊欄、配色和排版,只在適當位置加入以下區塊;如果既有頁面已經有統計卡片,也可以直接替現有元素加上相同的 id,不必把整頁換掉。
<section class="mt-8 space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<article class="rounded-2xl border border-slate-700 bg-slate-900 p-5">
<p class="text-sm text-slate-400">審查檔案</p>
<p id="report-file" class="mt-2 break-all text-lg font-semibold text-white">載入中…</p>
</article>
<article class="rounded-2xl border border-slate-700 bg-slate-900 p-5">
<p class="text-sm text-slate-400">發現問題</p>
<p id="issue-count" class="mt-2 text-3xl font-bold text-white">—</p>
</article>
<article class="rounded-2xl border border-slate-700 bg-slate-900 p-5">
<p class="text-sm text-slate-400">資安問題</p>
<p id="security-count" class="mt-2 text-3xl font-bold text-rose-300">—</p>
</article>
</div>
<div class="rounded-2xl border border-slate-700 bg-slate-900 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold text-white">審查明細</h2>
<p id="report-time" class="text-sm text-slate-400"></p>
</div>
<p id="report-status" class="mt-3 text-sm text-slate-400" role="status">正在載入報告…</p>
<div id="findings-list" class="mt-4 space-y-3"></div>
</div>
</section>
<script src="./dashboard.js" defer></script>
這段 HTML 只建立資料要放的位置。真正的內容由 dashboard.js 讀取 JSON 後填入,所以之後換一份審查報告,不需要重新調整版面。
在同一個 web/ 資料夾新增 dashboard.js:
const categoryLabels = {
security: "資安",
logic: "邏輯",
boundary: "邊界條件"
};
const reportFile = document.getElementById("report-file");
const issueCount = document.getElementById("issue-count");
const securityCount = document.getElementById("security-count");
const reportTime = document.getElementById("report-time");
const reportStatus = document.getElementById("report-status");
const findingsList = document.getElementById("findings-list");
async function loadReport() {
try {
const response = await fetch("./report.json", { cache: "no-store" });
if (!response.ok) {
throw new Error(`讀取 report.json 失敗:HTTP ${response.status}`);
}
const report = await response.json();
if (!report || !Array.isArray(report.findings)) {
throw new Error("報告格式錯誤:findings 必須是陣列");
}
reportFile.textContent = report.file || "未提供檔案名稱";
reportTime.textContent = report.generatedAt
? `產生時間:${report.generatedAt}`
: "未提供產生時間";
issueCount.textContent = String(report.findings.length);
securityCount.textContent = String(
report.findings.filter((item) => item.category === "security").length
);
findingsList.replaceChildren();
if (report.findings.length === 0) {
const emptyState = document.createElement("p");
emptyState.className = "rounded-xl border border-slate-700 p-4 text-slate-300";
emptyState.textContent = "這份報告沒有列出問題。仍建議確認測試結果,再判斷是否通過。";
findingsList.appendChild(emptyState);
}
report.findings.forEach((finding) => {
const card = document.createElement("article");
card.className = "rounded-xl border border-slate-700 p-4";
const title = document.createElement("h3");
title.className = "font-semibold text-white";
const line = Number.isFinite(Number(finding.line))
? `第 ${Number(finding.line)} 行`
: "行號未知";
const category = categoryLabels[finding.category] || "其他";
title.textContent = `${line} · ${category}`;
const reason = document.createElement("p");
reason.className = "mt-2 text-sm leading-6 text-slate-300";
reason.textContent = finding.reason || "沒有提供問題說明。";
const suggestion = document.createElement("p");
suggestion.className = "mt-2 text-sm leading-6 text-teal-200";
suggestion.textContent = `修改建議:${finding.suggestion || "請人工確認。"}`;
card.append(title, reason, suggestion);
findingsList.appendChild(card);
});
reportStatus.textContent = "報告載入完成。請搭配單元測試確認修復結果。";
} catch (error) {
reportStatus.textContent = `報告載入失敗:${error.message}`;
console.error(error);
}
}
loadReport();
這裡有兩個值得留意的地方。第一,fetch() 讀取 report.json,讓資料和 HTML 分開管理;第二,問題原因與修改建議都使用 textContent 寫入,而不是把 AI 回傳的字串直接交給 innerHTML 解讀。即使分析文字裡包含看起來像 HTML 的內容,也應該把它當成報告文字顯示,而不是讓它成為網頁的一部分。
如果直接用檔案總管開啟 index.html,網址會是 file://。部分瀏覽器會因此限制 fetch() 讀取本機 JSON。可以在終端機切到 web/ 目錄,用 Python 的簡易 HTTP Server 開啟本機頁面:
cd E:\2026ironman\devpulse\web
py -m http.server 8000
接著在瀏覽器開啟 http://localhost:8000。若專案不在上述路徑,請改成自己的 devpulse/web/ 位置;如果環境沒有 Python,也可以使用已安裝的本機靜態頁面伺服器。
驗收時先對照 report.json:
findings 的筆數?category 為 security 的項目?reason 暫時改成 <img src=x onerror=alert(1)>,重新載入後,它應該只會以文字出現在畫面上,不應執行任何事件;測完記得還原示範內容。今天把兩個原本分開的成果接在一起了:Genkit 負責產生結構化的審查結果,Stitch 頁面負責把結果呈現給人看。從此不只可以在終端機看到一段 JSON,也能在瀏覽器快速掌握問題數量、分類與修正建議。
不過,這仍是靜態報表展示:目前是把一次審查結果整理成 report.json,頁面載入時再讀取它。它還不會在每次執行 devpulse run 後自動更新,也不是即時監控服務。這個界線要說清楚,才不會把「能顯示資料」誤認成「資料已經自動串流」。
今天也延續了昨天的安全觀念:AI 產生的內容不只要檢查語意,也要注意它進入其他系統後會如何被解讀。把資料當作文字呈現,是一個小改動,卻能避免前端不必要的風險。
明天就是 Day 30。最後一天會把這 30 天碰過的 Gemini API、Zod、Genkit、Stitch 和 Antigravity 整理成一張知識地圖,回顧 DevPulse 從零件到作品的過程,並整理成可以帶走的個人作品集。